import { beforeAll, describe, expect, it, vi } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { render } from 'vitest-browser-react';
import { createRef } from 'react';

import Document from './Document.js';
import DocumentContext from './DocumentContext.js';
import { pdfjs } from './index.test.js';
import Page from './Page.js';

import { loadPDF, makeAsyncCallback, muteConsole, restoreConsole } from '../../../test-utils.js';

import type { PDFDocumentProxy } from 'pdfjs-dist';
import type LinkService from './LinkService.js';
import type { ScrollPageIntoViewArgs } from './shared/types.js';

const pdfFile = await loadPDF('../../__mocks__/_pdf.pdf');
const pdfFile2 = await loadPDF('../../__mocks__/_pdf2.pdf');

const OK = Symbol('OK');

function ChildInternal({
  renderMode,
  rotate,
  scale,
}: {
  renderMode?: string | null;
  rotate?: number | null;
  scale?: number | null;
}) {
  return (
    <div data-testid="child" data-rendermode={renderMode} data-rotate={rotate} data-scale={scale} />
  );
}

function Child(props: React.ComponentProps<typeof ChildInternal>) {
  return (
    <DocumentContext.Consumer>
      {(context) => <ChildInternal {...context} {...props} />}
    </DocumentContext.Consumer>
  );
}

async function waitForAsync() {
  await new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

describe('Document', () => {
  // Object with basic loaded PDF information that shall match after successful loading
  const desiredLoadedPdf: Partial<PDFDocumentProxy> = {};
  const desiredLoadedPdf2: Partial<PDFDocumentProxy> = {};

  beforeAll(async () => {
    const pdf = await pdfjs.getDocument({ data: pdfFile.arrayBuffer }).promise;
    desiredLoadedPdf._pdfInfo = pdf._pdfInfo;

    const pdf2 = await pdfjs.getDocument({ data: pdfFile2.arrayBuffer }).promise;
    desiredLoadedPdf2._pdfInfo = pdf2._pdfInfo;
  });

  describe('loading', () => {
    it('loads a file and calls onSourceSuccess and onLoadSuccess callbacks via data URI properly', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.dataURI}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(2);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);
    });

    it('loads a file and calls onSourceSuccess and onLoadSuccess callbacks via data URI properly (param object)', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={{ url: pdfFile.dataURI }}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(2);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);
    });

    it('loads a file and calls onSourceSuccess and onLoadSuccess callbacks via ArrayBuffer properly', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.arrayBuffer}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(2);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);
    });

    it('loads a file and calls onSourceSuccess and onLoadSuccess callbacks via Blob properly', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.blob}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(2);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);
    });

    it('loads a file and calls onSourceSuccess and onLoadSuccess callbacks via File properly', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.file}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(2);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);
    });

    it('fails to load a file and calls onSourceError given invalid file source', async () => {
      const { func: onSourceError, promise: onSourceErrorPromise } = makeAsyncCallback();

      muteConsole();

      // @ts-expect-error-next-line
      await render(<Document file={() => null} onSourceError={onSourceError} />);

      expect.assertions(1);

      const [error] = await onSourceErrorPromise;

      expect(error).toMatchObject(expect.any(Error));

      restoreConsole();
    });

    it('replaces a file properly', async () => {
      const { func: onSourceSuccess, promise: onSourceSuccessPromise } = makeAsyncCallback(OK);
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      const { rerender } = await render(
        <Document
          file={pdfFile.file}
          onLoadSuccess={onLoadSuccess}
          onSourceSuccess={onSourceSuccess}
        />,
      );

      expect.assertions(4);

      await expect(onSourceSuccessPromise).resolves.toBe(OK);
      await expect(onLoadSuccessPromise).resolves.toMatchObject([desiredLoadedPdf]);

      const { func: onSourceSuccess2, promise: onSourceSuccessPromise2 } = makeAsyncCallback(OK);
      const { func: onLoadSuccess2, promise: onLoadSuccessPromise2 } = makeAsyncCallback();

      await rerender(
        <Document
          file={pdfFile2.file}
          onLoadSuccess={onLoadSuccess2}
          onSourceSuccess={onSourceSuccess2}
        />,
      );

      await expect(onSourceSuccessPromise2).resolves.toBe(OK);
      await expect(onLoadSuccessPromise2).resolves.toMatchObject([desiredLoadedPdf2]);
    });
  });

  describe('rendering', () => {
    it('applies className to its wrapper when given a string', async () => {
      const className = 'testClassName';

      const { container } = await render(<Document className={className} />);

      const wrapper = container.querySelector('.react-pdf__Document');

      expect(wrapper).toHaveClass(className);
    });

    it('passes container element to inputRef properly', async () => {
      const inputRef = createRef<HTMLDivElement>();

      await render(<Document inputRef={inputRef} />);

      expect(inputRef.current).toBeInstanceOf(HTMLDivElement);
    });

    it('renders "No PDF file specified." when given nothing', async () => {
      const { container } = await render(<Document />);

      const noData = container.querySelector('.react-pdf__message');

      expect(noData).toBeInTheDocument();
      expect(noData).toHaveTextContent('No PDF file specified.');
    });

    it('renders custom no data message when given nothing and noData prop is given', async () => {
      const { container } = await render(<Document noData="Nothing here" />);

      const noData = container.querySelector('.react-pdf__message');

      expect(noData).toBeInTheDocument();
      expect(noData).toHaveTextContent('Nothing here');
    });

    it('renders custom no data message when given nothing and noData prop is given as a function', async () => {
      const { container } = await render(<Document noData={() => 'Nothing here'} />);

      const noData = container.querySelector('.react-pdf__message');

      expect(noData).toBeInTheDocument();
      expect(noData).toHaveTextContent('Nothing here');
    });

    it('renders "Loading PDF…" when loading a file', async () => {
      const { container } = await render(<Document file={pdfFile.file} />);

      const loading = container.querySelector('.react-pdf__message');

      expect(loading).toBeInTheDocument();
      await expect.element(page.getByText('Loading PDF…')).toBeInTheDocument();
    });

    it('renders custom loading message when loading a file and loading prop is given', async () => {
      const { container } = await render(<Document file={pdfFile.file} loading="Loading" />);

      const loading = container.querySelector('.react-pdf__message');

      expect(loading).toBeInTheDocument();
      await expect.element(page.getByText('Loading')).toBeInTheDocument();
    });

    it('renders custom loading message when loading a file and loading prop is given as a function', async () => {
      const { container } = await render(
        <Document file={pdfFile.file} loading={() => 'Loading'} />,
      );

      const loading = container.querySelector('.react-pdf__message');

      expect(loading).toBeInTheDocument();
      await expect.element(page.getByText('Loading')).toBeInTheDocument();
    });

    it('renders "Failed to load PDF file." when failed to load a document', async () => {
      const { func: onLoadError, promise: onLoadErrorPromise } = makeAsyncCallback();
      const failingPdf = 'data:application/pdf;base64,abcdef';

      muteConsole();

      const { container } = await render(<Document file={failingPdf} onLoadError={onLoadError} />);

      expect.assertions(2);

      await onLoadErrorPromise;

      await waitForAsync();

      const error = container.querySelector('.react-pdf__message');

      expect(error).toBeInTheDocument();
      await expect.element(page.getByText('Failed to load PDF file.')).toBeInTheDocument();

      restoreConsole();
    });

    it('renders custom error message when failed to load a document and error prop is given', async () => {
      const { func: onLoadError, promise: onLoadErrorPromise } = makeAsyncCallback();
      const failingPdf = 'data:application/pdf;base64,abcdef';

      muteConsole();

      const { container } = await render(
        <Document error="Error" file={failingPdf} onLoadError={onLoadError} />,
      );

      expect.assertions(2);

      await onLoadErrorPromise;

      await waitForAsync();

      const error = container.querySelector('.react-pdf__message');

      expect(error).toBeInTheDocument();

      await expect.element(page.getByText('Error', { exact: true })).toBeInTheDocument();

      restoreConsole();
    });

    it('renders custom error message when failed to load a document and error prop is given as a function', async () => {
      const { func: onLoadError, promise: onLoadErrorPromise } = makeAsyncCallback();
      const failingPdf = 'data:application/pdf;base64,abcdef';

      muteConsole();

      const { container } = await render(
        <Document error="Error" file={failingPdf} onLoadError={onLoadError} />,
      );

      expect.assertions(2);

      await onLoadErrorPromise;

      await waitForAsync();

      const error = container.querySelector('.react-pdf__message');

      expect(error).toBeInTheDocument();

      await expect.element(page.getByText('Error', { exact: true })).toBeInTheDocument();

      restoreConsole();
    });

    it('passes renderMode prop to its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.file}
          loading="Loading"
          onLoadSuccess={onLoadSuccess}
          renderMode="custom"
        >
          <Child />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.rendermode).toBe('custom');
    });

    it('passes rotate prop to its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document file={pdfFile.file} loading="Loading" onLoadSuccess={onLoadSuccess} rotate={90}>
          <Child />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.rotate).toBe('90');
    });

    it('passes scale prop to its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document file={pdfFile.file} loading="Loading" onLoadSuccess={onLoadSuccess} scale={1.5}>
          <Child />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.scale).toBe('1.5');
    });

    it('does not overwrite renderMode prop in its children when given renderMode prop to both Document and its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document
          file={pdfFile.file}
          loading="Loading"
          onLoadSuccess={onLoadSuccess}
          renderMode="canvas"
        >
          <Child renderMode="custom" />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.rendermode).toBe('custom');
    });

    it('does not overwrite rotate prop in its children when given rotate prop to both Document and its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document file={pdfFile.file} loading="Loading" onLoadSuccess={onLoadSuccess} rotate={90}>
          <Child rotate={180} />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.rotate).toBe('180');
    });

    it('does not overwrite scale prop in its children when given scale prop to both Document and its children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document file={pdfFile.file} loading="Loading" onLoadSuccess={onLoadSuccess} scale={1.5}>
          <Child scale={2} />
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByTestId('child').element();

      expect(child.dataset.scale).toBe('2');
    });

    it('supports function as children', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      await render(
        <Document file={pdfFile.file} loading="Loading" onLoadSuccess={onLoadSuccess}>
          {({ pdf }) => <p>{`This PDF has ${pdf.numPages} pages`}</p>}
        </Document>,
      );

      expect.assertions(1);

      await onLoadSuccessPromise;

      const child = page.getByText('This PDF has 4 pages');

      expect(child).toBeInTheDocument();
    });
  });

  describe('viewer', () => {
    it('calls onItemClick if defined', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      const onItemClick = vi.fn();
      const instance = createRef<{
        linkService: React.RefObject<LinkService>;
        pages: React.RefObject<HTMLDivElement[]>;
        viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
      }>();

      await render(
        <Document
          file={pdfFile.file}
          onItemClick={onItemClick}
          onLoadSuccess={onLoadSuccess}
          ref={instance}
        />,
      );

      if (!instance.current) {
        throw new Error('Document ref is not set');
      }

      if (!instance.current.viewer.current) {
        throw new Error('Viewer ref is not set');
      }

      expect.assertions(2);

      await onLoadSuccessPromise;

      const dest: number[] = [];
      const pageIndex = 5;
      const pageNumber = 6;

      // Simulate clicking on an outline item
      instance.current.viewer.current.scrollPageIntoView({ dest, pageIndex, pageNumber });

      expect(onItemClick).toHaveBeenCalledTimes(1);
      expect(onItemClick).toHaveBeenCalledWith({ dest, pageIndex, pageNumber });
    });

    it('attempts to find a page and scroll it into view if onItemClick is not given', async () => {
      const { func: onLoadSuccess, promise: onLoadSuccessPromise } = makeAsyncCallback();

      const instance = createRef<{
        linkService: React.RefObject<LinkService>;
        // biome-ignore lint/suspicious/noExplicitAny: Intentional use to simplify the test
        pages: React.RefObject<any[]>;
        viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>;
      }>();

      await render(<Document file={pdfFile.file} onLoadSuccess={onLoadSuccess} ref={instance} />);

      if (!instance.current) {
        throw new Error('Document ref is not set');
      }

      if (!instance.current.pages.current) {
        throw new Error('Pages ref is not set');
      }

      if (!instance.current.viewer.current) {
        throw new Error('Viewer ref is not set');
      }

      expect.assertions(1);

      await onLoadSuccessPromise;

      const scrollIntoView = vi.fn();

      const dest: number[] = [];
      const pageIndex = 5;
      const pageNumber = 6;

      // Register fake page in Document viewer
      instance.current.pages.current[pageIndex] = { scrollIntoView };

      // Simulate clicking on an outline item
      instance.current.viewer.current.scrollPageIntoView({ dest, pageIndex, pageNumber });

      expect(scrollIntoView).toHaveBeenCalledTimes(1);
    });
  });

  describe('linkService', () => {
    it.each`
      externalLinkTarget | target
      ${null}            | ${''}
      ${'_self'}         | ${'_self'}
      ${'_blank'}        | ${'_blank'}
      ${'_parent'}       | ${'_parent'}
      ${'_top'}          | ${'_top'}
    `(
      'returns externalLinkTarget = $target given externalLinkTarget prop = $externalLinkTarget',
      async ({ externalLinkTarget, target }) => {
        const {
          func: onRenderAnnotationLayerSuccess,
          promise: onRenderAnnotationLayerSuccessPromise,
        } = makeAsyncCallback();

        const { container } = await render(
          <Document externalLinkTarget={externalLinkTarget} file={pdfFile.file}>
            <Page
              onRenderAnnotationLayerSuccess={onRenderAnnotationLayerSuccess}
              renderMode="none"
              pageNumber={1}
            />
          </Document>,
        );

        expect.assertions(1);

        await onRenderAnnotationLayerSuccessPromise;

        const link = container.querySelector('a') as HTMLAnchorElement;

        expect(link.target).toBe(target);
      },
    );

    it.each`
      externalLinkRel | rel
      ${null}         | ${'noopener noreferrer nofollow'}
      ${'noopener'}   | ${'noopener'}
      ${'noreferrer'} | ${'noreferrer'}
      ${'nofollow'}   | ${'nofollow'}
    `(
      'returns externalLinkRel = $rel given externalLinkRel prop = $externalLinkRel',
      async ({ externalLinkRel, rel }) => {
        const {
          func: onRenderAnnotationLayerSuccess,
          promise: onRenderAnnotationLayerSuccessPromise,
        } = makeAsyncCallback();

        const { container } = await render(
          <Document externalLinkRel={externalLinkRel} file={pdfFile.file}>
            <Page
              onRenderAnnotationLayerSuccess={onRenderAnnotationLayerSuccess}
              renderMode="none"
              pageNumber={1}
            />
          </Document>,
        );

        expect.assertions(1);

        await onRenderAnnotationLayerSuccessPromise;

        const link = container.querySelector('a') as HTMLAnchorElement;

        expect(link.rel).toBe(rel);
      },
    );
  });

  it('calls onClick callback when clicked a document (sample of mouse events family)', async () => {
    const onClick = vi.fn();

    const { container } = await render(<Document onClick={onClick} />);

    const document = container.querySelector('.react-pdf__Document') as HTMLDivElement;
    await userEvent.click(document);

    expect(onClick).toHaveBeenCalled();
  });

  function triggerTouchStart(element: HTMLElement) {
    element.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true }));
  }

  it('calls onTouchStart callback when touched a document (sample of touch events family)', async () => {
    const onTouchStart = vi.fn();

    const { container } = await render(<Document onTouchStart={onTouchStart} />);

    const document = container.querySelector('.react-pdf__Document') as HTMLDivElement;
    triggerTouchStart(document);

    expect(onTouchStart).toHaveBeenCalled();
  });

  it('does not warn if file prop was memoized', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const file = { url: pdfFile.dataURI };

    const { rerender } = await render(<Document file={file} />);

    await rerender(<Document file={file} />);

    expect(spy).not.toHaveBeenCalled();

    vi.mocked(globalThis.console.error).mockReset();
  });

  it('warns if file prop was not memoized', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const { rerender } = await render(<Document file={{ url: pdfFile.dataURI }} />);

    await rerender(<Document file={{ url: pdfFile.dataURI }} />);

    expect(spy).toHaveBeenCalledTimes(1);

    vi.mocked(globalThis.console.error).mockReset();
  });

  it('does not warn if file prop was not memoized, but was changed', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const { rerender } = await render(<Document file={{ url: pdfFile.dataURI }} />);

    await rerender(<Document file={{ url: pdfFile2.dataURI }} />);

    expect(spy).not.toHaveBeenCalled();

    vi.mocked(globalThis.console.error).mockRestore();
  });

  it('does not warn if options prop was memoized', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const options = {};

    const { rerender } = await render(<Document file={pdfFile.blob} options={options} />);

    await rerender(<Document file={pdfFile.blob} options={options} />);

    expect(spy).not.toHaveBeenCalled();

    vi.mocked(globalThis.console.error).mockRestore();
  });

  it('warns if options prop was not memoized', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const { rerender } = await render(<Document file={pdfFile.blob} options={{}} />);

    await rerender(<Document file={pdfFile.blob} options={{}} />);

    expect(spy).toHaveBeenCalledTimes(1);

    vi.mocked(globalThis.console.error).mockRestore();
  });

  it('does not warn if options prop was not memoized, but was changed', async () => {
    const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {
      // Intentionally empty
    });

    const { rerender } = await render(<Document file={pdfFile.blob} options={{}} />);

    await rerender(<Document file={pdfFile.blob} options={{ maxImageSize: 100 }} />);

    expect(spy).not.toHaveBeenCalled();

    vi.mocked(globalThis.console.error).mockRestore();
  });

  it('does not throw an error on unmount', async () => {
    const { func: onLoadProgress, promise: onLoadProgressPromise } = makeAsyncCallback();

    const { unmount } = await render(<Document file={pdfFile} onLoadProgress={onLoadProgress} />);

    await onLoadProgressPromise;

    expect(unmount).not.toThrowError();
  });
});
